iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0

在上一篇的「房門與門鎖」篇章中,我們已經完成了登入 / 註冊表單的設計,讓使用者能夠順利進出這棟「房子」🙂。但光是蓋好門,還不足以保證整體的品質。接下來就要進入新的篇章 — 「屋況驗收」。

就像房子蓋好之後,還需要驗收管線、電路、水泥結構是否牢靠一樣,在軟體開發中,我們也需要透過測試來驗證功能是否真的能如預期運作。本篇將帶你初始化 Vitest,並撰寫第一個單元測試,一步步為系統打下更可靠的基礎👍。

validSchema.test.ts 的完整測試結果

https://ithelp.ithome.com.tw/upload/images/20250924/20110586cS623qPaIh.png

本篇重點整理:

  • 測試工具:簡單介紹其角色定位,並講解測試的一些常見問題
  • Vitest初始化:實作完整的初始化流程
  • 單元測試:以範例程式碼簡單介紹基本語法
  • 單元測試實作:以 validSchema.ts 為例,展示部分程式碼參考

測試

這邊來延續本系列的傳統來說明本次會用到的工具:

  • 測試框架 ( Jest、Vitest ) — 驗屋員🕵️
    專案就像蓋好的房子,雖然看起來完整,但只有「實際驗收」才能確保房子能不能安全入住。測試框架就像驗屋員一樣,會逐一檢查每個環節是否符合標準。Jest 是老牌選擇,生態系龐大;Vitest 則是新世代測試工具,與 Vite 緊密整合,啟動快、開發體驗好,對於現代 React + TS 專案更合適。
  • 模擬瀏覽器環境 ( jsdom、happy-dom ) — 模擬樣品屋 🏠
    有些測試需要「假裝在瀏覽器中」才跑得起來,例如按按鈕、填表單。這時候就需要像 jsdom 或 happy-dom 這樣的模擬環境。它們就像樣品屋,雖然不是實際的房子,但能讓我們模擬住進去、打開窗戶或關燈的場景,測試互動是否正常。本篇我們會使用 happy-dom,因為它比 jsdom 更輕量,也更適合和 Vitest 搭配。

為什麼需要測試?

或許你會想:

我在瀏覽器跑一跑,出錯不就會看到白屏或錯誤訊息了嗎🤔?

問題是,專案越大、功能越多,錯誤可能發生在許多環節,並不會乖乖顯示在哪裡爆掉。更糟的是,今天你修好了登入功能,明天加了新需求卻不小心把舊功能搞壞了😓,這就是所謂的回歸錯誤

測試的價值在於:

  • 精準找出問題:比單靠瀏覽器更快速可靠。
  • 避免人為疏失:不用全靠開發者「肉眼檢查」,測試自動幫你驗證。
  • 提升專案信心:每次改動程式碼,都能放心知道「沒把別的東西弄壞」。

就像蓋房子時,如果沒有驗屋員逐一檢查水電、結構,搬進去後才發現問題,代價會非常高。


測試類型簡介

在專案中常見的測試大致分為:

  • 單元測試 (Unit Test):專注在最小單位,例如檢查某個函式回傳值是否正確。
    • 像是 authApiCall.tspasswordStrength.tsvalidSchema.ts
  • 整合測試 (Integration Test):檢查不同模組之間是否能正常運作,例如 API 請求加上 UI 渲染是否正確。
    • 像是 LoginForm.tsxInputField.tsx
  • 端對端測試 (E2E Test):模擬使用者實際操作流程,例如「使用者開啟頁面 → 輸入帳密 → 按下登入 → 成功跳轉」。

而在本篇,我們會專注在「 單元測試 」,驗證一些最小單位的邏輯與 UI 元件。


測試時應該怎麼做測試? 🧪

寫測試的時候,並不是只有「正常情況」要檢查,還需要從不同角度來驗證,確保功能真的健壯:

  • 正確的情況 ( Happy Path )
    這是使用者最常遇到的流程,例如輸入合法的 email、點擊登入按鈕後正確跳轉。
    先確保「正常狀況」能夠順利運作,這是測試的基礎。
  • 錯誤或失敗的情況 ( Bad Path )
    模擬使用者操作錯誤,像是輸入錯誤密碼、email 格式不正確、API 回傳錯誤。
    這能測試系統是否有良好的錯誤處理,並給予正確提示(例如「密碼錯誤」的 toast)。
  • 邊界狀況 ( Edge Cases )
    嘗試一些「極端或少見」的狀況,例如空字串、超長字串、特殊符號,或 API 回傳極端數據。

這通常是 bug 容易藏身的地方,寫測試能避免這些情況影響使用者體驗。


撰寫測試的時機點⏳

其實沒有一定的標準,但有一些比較實務的節點可以參考:

  1. 功能完成後立即寫測試
    • 避免拖延,功能細節還在腦中時最好寫。
  2. 在 Git merge 前完成對應測試
    • 確保 main 分支永遠維持在可運行、可測試狀態。
  3. Bug 修正時補測試
    • 每次修 bug,加一個測試確保未來不會回歸。
  4. 大功能整合完,再加 E2E 測試
    • 不需要一開始就寫 E2E,等到流程比較穩定再來驗收整體操作。

🔰上一個主題「房門與門鎖」本身就是一個功能的收尾,把測試篇章放在這裡是為了呈現一個「 功能開發 → 驗收測試 」的完整流程。往後每個主題收尾時就多加贅述與撰寫測試篇章。


Vitest 初始化

在 React + TypeScript 專案中,我們可以這樣安裝測試工具:

npm i -D vitest happy-dom @testing-library/jest-dom @testing-library/react @testing-library/user-event

接著,創建 vitest.config.ts

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "happy-dom",
    globals: true,
    setupFiles: "./src/setupTests.ts",
  },
});

新增 setupTests.ts

import "@testing-library/jest-dom";

並在 package.jsonscript 中加入:

"test": "vitest"

這樣就完成基本初始化了 🎉


單元測試

通常,單元測試會是所有測試檔案裡行數最多的,畢竟他是整個測試的根基,必須要將底打好,為後續的整合測試做好鋪墊。

所有的測試檔案都會是 .test.xx 形式,像是 validSchema.test.tsInputField.test.tsx

以下我用一個簡單範例做解說:

import { describe, it, expect } from "vitest";
import { emailSchema } from "../lib/validSchema";

describe("emailSchema 驗證", () => {
  it("應該通過合法的 email", async () => {
    const result = await emailSchema.isValid("test@example.com");
    expect(result).toBe(true); // ✅ 預期為 true
  });

  it("應該拒絕不合法的 email", async () => {
    const result = await emailSchema.isValid("invalid-email");
    expect(result).toBe(false); // ❌ 預期為 false
  });
});
  • describe:測試群組,可以把相關測試歸類在一起。這裡的主題是「emailSchema 驗證」。
  • it:單一測試案例,用來描述「要測什麼」。
  • expect:斷言工具,檢查程式輸出的結果是否符合我們的預期。
    expect(result).toBe(true) 代表:如果結果不是 true,測試就會失敗。

簡單認識後,接下來就開始製作吧!


部分測試程式碼:

validSchema.test.ts

describe("validSchema 單元測試", () => {
  describe("Login Schema 登入驗證", () => {
    it("應該成功驗證有效的登入資料", async () => {
      const validLoginData = {
        username: "testuser123",
        password: "Password123!",
        recaptcha: "some-recaptcha-token",
      };
      await expect(loginSchema.validate(validLoginData)).resolves.toBe(
        validLoginData
      );
    });

    it("應該在使用者名稱為空時拋出錯誤", async () => {
      const invalidLoginData = {
        username: null,
        password: "Password123!",
        recaptcha: "some-recaptcha-token",
      };
      await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
        "* 使用者名稱為必填欄位"
      );
    });

    it("應該在使用者名稱少於 7 個字元時拋出錯誤", async () => {
      const invalidLoginData = {
        username: "short",
        password: "Password123!",
        recaptcha: "some-recaptcha-token",
      };
      await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
        "* 使用者名稱最少為 7 個字元"
      );
    });

    it("應該在使用者名稱超過 16 個字元時拋出錯誤", async () => {
      const invalidLoginData = {
        username: "toolongusername12345",
        password: "Password123!",
        recaptcha: "some-recaptcha-token",
      };
      await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
        "* 使用者名稱最多為 16 個字元"
      );
    });

    it("應該在使用者名稱包含無效字元時拋出錯誤", async () => {
      const invalidLoginData = {
        username: "invalid@user",
        password: "Password123!",
        recaptcha: "some-recaptcha-token",
      };
      await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
        "* 使用者名稱只能包含英文字母、數字、底線( _ )和減號( - )"
      );
    });

    it("應該在密碼為空時拋出錯誤", async () => {
      const invalidLoginData = {
        username: "testuser123",
        password: null,
        recaptcha: "some-recaptcha-token",
      };
      await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
        "* 密碼為必填欄位"
      );
    });

    it("應該在密碼少於 8 個字元時拋出錯誤", async () => {
      const invalidLoginData = {
        username: "testuser123",
        password: "short",
        recaptcha: "some-recaptcha-token",
      };
      await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
        "* 密碼最少為 8 個字元"
      );
    });

    it("應該在密碼超過 20 個字元時拋出錯誤", async () => {
      const invalidLoginData = {
        username: "testuser123",
        password: "toolongpassword1234567890",
        recaptcha: "some-recaptcha-token",
      };
      await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
        "* 密碼最多為 20 個字元"
      );
    });

    it("應該在密碼包含無效字元時拋出錯誤", async () => {
      const invalidLoginData = {
        username: "testuser123",
        password: "password with space",
        recaptcha: "some-recaptcha-token",
      };
      await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
        "* 密碼只能包含英文字母、數字和常見特殊字元"
      );
    });

    it("應該在 reCAPTCHA 為空時拋出錯誤", async () => {
      const invalidLoginData = {
        username: "testuser123",
        password: "Password123!",
        recaptcha: null,
      };
      await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
        "* 請完成 reCAPTCHA 驗證"
      );
    });
  });
});

參考資料

Vitest 官方 Getting Started 文檔


上一篇
房門與門鎖[ 6 / 6 ]:完整流程 — Axios + JSON Server + Toast
下一篇
屋況驗收[ 2 / 2 ]:Vitest 覆蓋率 & 整合測試
系列文
不只是登入畫面!一起打造現代化登入系統12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言